VitalRouter.MRuby - Unity向け汎用Rubyスクリプティングフレームワーク
元々、VitalRouter というUnity向け高速メッセージングライブラリをつくっていたんですが、ここに mruby を組み込むことによって、Rubyスクリプトで書いたとおりにメッセージを発行(publish)できるという代物、それが VitalRouter.MRuby 、です。 https://scrapbox.io/files/6708a7b9c648bb001c97d597.png
これによって、ゲームのシステム部分の開発のレイヤの上に、お手軽ゆるゆるスクリプトを乗っけることができます。
What is VitalRouter?
VitalRouter自体は単に、中央イベントアグリゲータ/メッセージブローカー/メディエーター的なパターンを持ち込めるライブラリです。
僕の考えでは、ゲームプログラミングの設計でややこしいところのひとつがこの「イベント/メッセージ発行」みたいなところにあります。ゲーム世界では、あるオブジェクトから発火したイベントが、巡り巡ってとてもたくさんのオブジェクトに影響を及ぼすことはよくあり、よくあるどころかその関係性の数は日を追うごとに爆発的に増加、イベント購読してる側だったはずの奴がいつのまにか逆にイベントを乱れ飛ばしていたり、東から昇った太陽が西から昇ったりと、とかく混乱が生じやすい領域です。
VitalRouter をつかうと、特定のオブジェクトからは分離した形で中央メッセージブローカー的なものを設置できるので、N:N関係の整理に役立ちます。受信側は、asyncハンドラ/asyncミドルウェア/順序制御 などを宣言的に行うことができるので、単方向制御フローを推進するための薄いフレームワークとしても機能します。
https://scrapbox.io/files/6708a498d7566f001c86de07.png
昨今のUIプログラミングの潮流では、イベント発火から終端のオブジェクトに変更を適用する迄のデータの流れは単方向にするのが正義になってきてます。そこにはいろいろ理由はありますが、メッセージの指向性を意識することはアプリの設計をする上でけっこう重要です。VitalRouterはそこを取り入れつつ、加えてサーバサイドプログラミングのコントローラのようなノリでイベントハンドラを宣言できるようになってます。
code:cs
public partial class ExamplePresenter
{
// 同期ハンドラ
public void On(FooCommand cmd)
{
// Do something ...
}
// 非同期ハンドラ
public async UniTask On(BarCommand cmd)
{
// Do something for await ...
}
}
( 似た目的のものに Cysharp/MessagePipe もありますが、VitalRouter はより中央のメッセージブローカーとしての用途に特化していること、SourceGenerator に頼った宣言的API を持っていること、R3など後発のデザインや最適化手法も参考にしていることなどなどの違いがあります) Note:
一般に昨今のUIプログラミングの世界では、UIコンポーネントの宣言のなかにイベントの購読も隠蔽したり、あるいはUIコンポーネントが自律的に値の変化に反応するように見せかける、など、UIコンポネにある意味で制御フローもかち込むようになってきており、コンポねの役割が広くなってきてるようです。とはいえ、ゲームプログラミングでは、終端のコンポーネントの役割や時空間的な粒度はけっこうまちまちで、あるプロパティを単にUIに写す、というモデルでは完結しずらく、そこにはプログラミングの関心の違いがあります。そのため、UIプログラミング一般で普及しているパタンをただ持ち込めば解決できる領域は限られています。個人的にはあいかわらずController/Presenterなどの制御フローのハブ的なものをViewコンポーネントとは独立して切り出すパタンは有用であり続けていると思ったり思わなかったりしてます。
ゲームとスクリプト言語組み込み
というようなライブラリを使いつつ、ゲーム内の出来事をメッセージで表現できるようにするのはある種のパターンのひとつで、けっこうおもしろい設計です。
ゲームのロジックが動くまでには色々な入力源が考えられます。典型的にはボタンを押したとかの人間からの入力ですが、そのほかにも、AI(NPC)の決定による不特定タイミングでのロジックの作動、サーバから受信したリアルタイム性の高いデータの到着による割り込み、などが考えられます。とにかく様々ですが、けっきょくぜんぶ同種のメッセージのハンドラに集約してしまえば、すべての入力を統一的な規格で扱うことができ、以下のような応用も見えてきます。
エラー処理やロギングなど共通のアレを挟んだり、キューに入れて順序を制御したり、
発行されたメッセージを時系列に保存しておくとリプレイとか自動プレイとか実装できるし、
シナリオやキャラクターの演技など、一連のシーケンスを編集・保存しておいて再生したい場合にも使えるし、
といったことを考えていったとき、VitalRouterのようなライブラリに mruby をくっつける便利さが見えてきます。
ゲーム開発ではしばしば、メインのゲームシステムを書くプログラミング言語のほかに、さらに追加で簡単軽量なスクリプト言語を乗っける、といった試みが伝統的に行われがちです。
ゲームというソフトウェアのコンテンツは、制作者が編集しやすいツールでいじったデータによってバリエーションがつくられていて、典型的には以下のような仕組みが組み込まれることがふつーです。
エクセルとかで編集したデータシートをなんらかのフォーマットで保存しておき、ゲーム実行時に読み込んでアレする。
そのゲームに特化したGUIツールでコンテンツを編集できる仕組みをつくり、GUIツールから出力したデータを実行時に読み込んでアレする。
タイムラインエディタ
ノードエディタ
とかそのゲームに特化したツールは色々ありえる。
メインのプログラミング言語のほかに、ホットリロード可能な軽量スクリプト言語/バイトコードマシンなどを組み込んでおく。
たとえばゲームのなかに、以下のようなキャラクターの会話や動作、演技を実装したい場合を考えてみます。
code:ひげよさらば
片目「へんな猫だな」
(片目、目を細める)
片目「何も覚えていないというのに、どうして名前だけは覚えているんだ」
(間 0.5秒)
ヨゴロウザ「どうしてだろう……?」
こういう「へんな猫だな」とかセリフの文字は、普通はプログラマがCぷらぷらだC#だといったソースとしてハードコードすることはなく、シナリオとして把握・編集が容易な外部データとツールで管理されてます。
あるとき「へんな猫」を「変な猫」に変えよっか? とか思ったとき、いちいちコンパイルぜずともホットリロードできると生産的だし、専用のエディタ/ツールで編集できるほうがコンテンツの内容に集中でき、分業もしやすいです。ゲームのシステムのコードに対しての影響範囲も制限できます。
データをファジーなかんじで管理できるエクセルは、割とゲームのマスターデータ管理ツールとして人気があるらしく、ノベルゲームをエクセルで作成できるようなソリューションも世の中にありますが、エクセルは向いてないデータ構造にはとことん向いてないし、グラフ構造のようなものを表現するのは得意なわけではない。
GUIツールは、そのゲームに特化したオーダメイドのものをつくることになりがちですが、開発リソースがけっこうかかったり、生産性の高いツールをつくるのはそれ自体が探求しがいのある一大プロジェクトだったりします。
ここにつけ加えたい最後の選択肢が、スクリプト言語です。スクリプト言語は無限の汎用性をもちながら、シナリオなど時系列のステップや木構造/グラフ構造のデータを表現する、という目的に実は適してます。スクリプト言語を組み込むこと自体にややハードルがありますが、そこを越えると大してツール開発の手間は少ないので、インディ小規模ゲーム開発者などは割とマッチするソリューションだと思うんですよね。
VitalRouter.MRuby は、こういった用途を想定しつつ、スクリプト組み込みの手間はほぼなく C# でイベントハンドラを宣言するだけで使い始められるようになってます。
ゲーム組み込み言語としてのruby、そしてLua
ゲームへの組み込みスクリプト言語としての事例が多いのはLua で、Unityでは C#で実装されたLua処理系をぶちこむことで、C# - Lua 相互に通信したりしなかったりといったことが可能です。
Luaの最大の特徴は仕様が少なく組み込みに特化してるところ。この特徴から、無数のオルタナティブ野良Lua実装が存在し、ピュアC# 実装でさえ複数存在するっぽいほどです。逆に言えば、Luaだけでアプリを書くとかいった行為にはあまりお目にかかることがないので、書いたことがある人は少なめ。また、文法はシンプルで、仕様が少ないところは長所ではありますが、短所でもあります。
人気の汎用プログラミング言語のなかには、いわゆるDSL(ある特定の目的に制限された設定ファイルとか)をつくるのが得意と評されるものがいくつかあります。最近だとKotlinなんかは、アプリを書けるのと同時に、みやすく構造化された設定ファイルを記述するための機能が極端に意識されていて逆にこれ大丈夫か?ってかんじの言語として有名。KotlinユーザはKotlinでアプリを書きつつ、KotlinでGradleをはじめとした設定ファイルも書いて生活していると言われてます。
gradleの設定ファイルといえばたとえばこんなんですね。
code:gradle.kt
plugins {
id("org.springframework.boot") version "2.7.2"
id("io.spring.dependency-management") version "1.0.13.RELEASE"
id("me.qoomon.git-versioning") version "6.3.0"
}
gitVersioning.apply {
refs {
considerTagsOnBranches = true
tag("v(?<version>\\d+.\\d+.\\d+)") { version = "\${ref.version}"
}
}
}
ここでのポイントは、ネストした構造を最低限の記号で表現でき、かつブロックで括られた内側が どういうコンテキストになるか自由に設定できる、といったところです。この機能によって、上記のようにブロックで括られた表現が何段階に渡っても内側がすっきりしてます。
(でもこういうのって、ブロックの内側で使えるメソッドやプロパティの手がかりが全然ないから 補完ビリティは低いような気はするけどね)
こういったDSLが得意な言語と比較すると、Luaはネストした構造の表現力やシンタックスノイズの少なさなどはそこまで秀でているわけではないように思えます。あくまでシンプルさやめちゃ組み込みしやすいところが長所なためです。
一方、VitalRouter.MRuby に組み込まれているRubyという言語は、DSLが得意中の得意で、ほとんどその分野の先駆者。先に挙げたKotlinもRubyから多大すぎる影響を受けてます。Kotlinを空手とするならばRubyは中国拳法かってくらいDSL探求の歴史を持っています。
Railsが栄華を極めた時代、ActiveSupport や RSpecなどによって、「字面が自然言語っぽくなるか」「ネストしたブロックとその中身をいかにすっきり記述できるか」といった探求は、やり過ぎて後年ゆり戻しが来るほど徹底的にやられました。instance_evalとかを使ったコンテキストを束縛したブロック呼び出しや、文字列や数値などリテラルの拡張、そもそも記号を書かずにメソッドを使えるなどなどの機能にRubyは特化しているためです。
上で挙げたシーケンスをRubyで書こうと思ったとき、たとえば以下のように、記号類が極端にすくないテキストで表現できます。
code:ruby
talk 片目: 'へんな猫だな'
talk 片目: '何も覚えていないといのに、どうして名前だけは覚えているんだ'
wait 0.5.secs
talk ヨゴロウザ: 'どうしてだろう……?'
(あくまで一例。好き好きでしょうね…)
ゲームのシナリオなどのシーケンスを書くときに、あえてまじのスクリプト言語を採用する利点のひとつは、この辺の表現力にあります。
ネスト構造化をもうすこし意識すると以下のような記述も考えられます。
code:ruby
with(:ヨゴロウザ) do
talk 'あんたは誰だ'
motion :suprize
end
with(:黒猫) do
talk 'おまえじゃないか'
motion :laugh
talk 'おまえは、じぶんのことをだんだん忘れていくようだな'
talk 'そうじゃないかね'
end
(あくまで一例)
これが良いか悪いかはともかく、その場のドメインに特化した設定ファイルをデザインすることがRubyは得意なんですよね。本来は巨大アプリを書くっていうより、こういう用途で活きる言語でもあり、それが Matz氏がmrubyに取り組んでいる所以でもあるんでしょう。うん。
ちなみに、Ruby がゲームで使用されている事例で有名なのものに以下がある模様。
NieR:Automata
mruby
Rubyは仕様が単純ではなく、相対的にはLuaよりも処理系、特にパーサの実装がかなりむつかしいと思うんですが、Ruby作者Matz氏が取り組んでいる 軽量組み込み向けRuby実装が存在します。それがmruby。
mrubyには以下のような特徴があります。
単一のライブラリ libmruby としてビルドできる。
Cの標準ライブラリ (libc と libm(math)) を除くと外部ライブラリ非依存
最小ビルドの libmruby.a が約700KBくらい(?)。メモリ使用量は100KBくらい。らしい。
Luaよりはでかいと思うが、v8とかに比べるとだいぶ小さい
機能を取捨選択してビルドできる
Ruby標準ライブラリであってもいらないものは取り外して軽量化したビルドを作成できる
正規表現とかなんかたぶん絶対重そうなもの、I/Oなどゲーム組み込み用途でいらなそうなものはすべて除外した状態のカスタムビルドを作成できる。
仮想マシンランタイム
コンパイル → バイトコード → 仮想マシン
mrubyコンパイラを取り外し、バイトコードを食うだけのランタイムだけゲームに組み込むことも可能
そんなmrubyですが、Unityへ組み込む上での弱点は、C#によるrubyやmruby実装が存在しないことです。mrubyはC言語でできている上、自前でカスタムビルドをしてこそ真価が発揮できるみたいなところがあるため、プラットフォーム毎にネイティブライブラリをビルドする必要があり、運用上、ポータビリティがやや劣ります。
また、C#からmruby のAPIを操るにはC# - C間で FFI する必要があって、ふつうに考えるとC#のメソッドをRubyから呼べるようにバインディングを書くためには CのABIを経由するため、めんどい。
そこで、RubyからC#をそのまま呼べるバインディングを書くという方法は捨てました。
代わりに、Ruby(スクリプト)からVitalRouterへ「メッセージ」を発行できる、というモデルを採用することによって、本家mrubyをそのまま組み込みつつ、汎用性が高く、かつ実用的で超軽量なフレームワークを目指すことにしました。RubyからUnityの機能すべてにアクセスすることは特に理想ではないし領域侵犯は複雑すぎるものを生みかねないので、システムはC#、スクリプティングしたいところだけRuby、と住み分ければ問題ナシだ。
たとえばUnity 側で以下のようなコマンドを定義しておくと、
code:vitalrouter.cs
// コマンド型の定義
public struct MoveCommand : ICommand
{
public string Id;
public Vector2 To;
}
code:preset.cs
// コマンドの名前の登録
class MyCommands : MRubyCommandPreset;
Rubyの cmd メソッドで上記で宣言したコマンドを発行できます。
code:vitalrouter.rb
cmd :move, id: "Bob", to: 5, 5 発行されたコマンドはC#側ではハンドラを書くだけで処理できます。
code:handler.cs
async UniTask On(MoveCommand cmd)
{
// async sequences
await MoveToAsync(cmd.Id, cmd.To);
}
NOTE:
引数を型づけするのはVitalRouter側が要求する制約ですが、このことによってシリアライズ可能になったり、型がコマンド種の一意な識別子として機能したりと様々なメリットがあるのでこうなってます。くわしくは VitalRouter の README参照。 つまりなんでもできます。
これだけでもけっこう十分だとおもうんですが、先に紹介したようにもっと自前でRubyのDSLをつくりたい、という気持ちになった場合は Ruby でカスタムメソッドをつくれば可能性は無限大です。
code:lib.rb
# メソッド定義すると…
def move(id, to) = cmd :move, id:, to:
# こう書ける。
code:lib2.rb
# クラス定義や instance_eval を駆使すると…
class CharacterContext
def initialize(id)
@id = id
end
def move(to) = cmd :move, id: @id, to: to
end
def with(id, &block)
CharacterContext.new(id).instance_eval(block)
end
# こう書ける。
with(:Bob) do
end
C# 側では以下のように任意のmrubyコードを評価できるようになってるので、メソッド、クラス、なんでも追加可能。
code:load.cs
context.Load("def move(id, to) = cmd :move, id:, to:")
また、C#側の状態によってRubyのスクリプトを分岐させたい、などのニーズは当然あると思います。これについては、C#とRuby双方から参照できる SharedState というものを用意してます。
code:hoge.cs
// C#側から任意の変数をセット
context.SharedState.Set("flag_hoge", true)
code:hoge.rb
# Ruby側でそれを見て分岐!
# ...
else
# ...
end
rubyのFiberとC#のasync/await連携
VitalRouter.MRuby の特徴のひとつに、Rubyの「Fiber」とC#のasync/awaitの統合があります。
https://scrapbox.io/files/6708a4aea74e86001c7701b5.png
VitalRouter.MRubyで実行されるRubyは、基本的には Fiber(中断/再開可能な外部イテレータ)の上で動いてます。
この例では、最初の cmd でコマンドをC#側へ発行した後、Ruby側は一旦実行がサスペンドされ、待機状態になります。この間、CPUリソースを使ったりメインスレッドをブロックすることがないため、Unity WebGLなどのシングルスレッド環境でもゲームはスムーズに動き続けます。
待機状態は C#のasync ハンドラの実行が完了するまで続き、それを待ってから続きが再開されます。
この例では、C#側は ユーザがボタンが押すまでそのフレームをスキップしながら待ち、ボタンが押されたら await の行を抜け、Rubyに制御を戻します。
wait 命令も同様で、これは C#で await UniTask.Delay(...) しているのと同義です。
ゲーム開発では、複数のフレームをまたいだりユーザの入力やI/Oなど不特定タイミングで完了する処理を待ってから次へ進む、といった、非同期な処理の連鎖がいたるところにでてきます。先の例の「画面にテキストを表示する」もユーザ入力を待ってから次のテキストを送る、といった制御が必要で、これは同期的なプログラミングだけではまったくうまく書けまへん。
C# ではそこを async/awaitを駆使して制御できるところが最高なわけですが、
VitalRouter.MRubyのRubyスクリプトは、めんどいキーワードを書かなくても暗黙のうちにコルーチンとして動作するようにデザインしてます。この、「見た目はふつーのメソッド呼び出しなんだけどコルーチン」、というのは最近ではKotlin とか(ある意味ではGo)が採用してるデザインです。あと有名どころでは k6 とかもそんなかんじ。「DSL内ではawaitとか書きたくないっす」これを RubyのFiberで実現しています。
(これはmrubyの利用例としてけっこう知られている nginx-mruby を参考にしている)
RubyのFiberは、「軽量スレッド」として紹介されることがあるようですが、その説明はやや誤解を生みがちで、実際のところFiberはスレッドていうか C#でいう外部Enumerator、jsでいうジェネレータ、のようなものに近いです。
つまり自身で中断し、外側からの命令で再開できるコード片。
これを本当に並行実行の道具として使用するためには、Fiberの外側に並行実行ランタイムが存在することが必須で、そこからFiberを操ってあげる必要があります。それがいわゆる軽量スレッドと聞いて期待する挙動じゃないでしょか。
この場合、mrubyからみて「外側の並行実行ランタイム」とはなにか、そうC# の async/await エコシステムですね。C#最強。
Fiber は C の API から制御できるので、実はFiberというのはmruby のように組み込み用途でこそ真価を発揮する機能なのではないでしょか。
mrb_value - C# のダイレクト変換
VitalRouter.MRuby は、mruby → C# へメッセージを送信できるのですが、内部的には、mruby世界のソースコードの評価結果をC#の型に変換する機能を持ってます。
code:evaluate.cs
context.Evaluate<int>("12345") //=> 12345
context.Evaluate<double>("1.23 * 10") //=> 12.3;
context.Evaluate<Vector2>("123, 456") //=> Vector2(123, 456) [MRubyObject] アトリビュートを使うと、自作の型もRubyから直で受け取れます。
code:mrubyobj.cs
public class Foo
{
public int X;
public string S;
}
code:evaluate.rb
context.Evaluate<Foo>("{ x: 123, s: 'hoge' }") //=> Foo(123, "hoge")
これは、mruby世界のオブジェクトのメモリ表現を、C#世界のメモリ表現に変換するシリアライザを実装することで実現しています。
mruby世界では、Rubyオブジェクトはすべて mrb_value という値で表現されているんですが、C#でこの中身を読み込んでC#型へ変換する仕組みです。
mrb_value のフォーマットは現在3種類ほどあり、mrubyビルド時のオプションで設定できるんですが、一番素朴な未圧縮のフォーマットはC#のblittableな値へのマッピングがけっこう割と素直にできたりします。
なのでほぼパーサを書く必要なし。僕は 以前、C#でYAMLパーサ/エミッター/シリアライザ をスクラッチで書いたことがある (VYaml ) んですが、それに比較すると簡単です。 denoはRustに v8 を組み込んだjsランタイムですが、v8のオブジェクトをrustの型へ変換する仕組みを持っているんですね。これによってdeno内では rustからjs、jsからrustへなんか複雑なデータを渡すコードがかなり省力化されてます。
NOTE
ここでの注意点として、mrb_valueはmrubyがgcで管理しているものなので、単純にCからC#へ渡して保持し続けようとすると危険。なので、明示的にGC対象にする/対象から外すといった管理が挟まってます。
( cmd についてはCのコンテキスト内で完了するのでこういったオーバヘッドはない
NOTE
mruby には GC専用スレッドもないし、コンパクションも現状ないようです(たぶん)
なので厳密には次のruby実行までは mrb_valueを掴み続けていてもたぶん問題ない
Unityネイティブアロケータ
mruby は様々な機器を想定しているためか、メモリアロケータをC標準の malloc/free でないものに差し替えることができます。
https://scrapbox.io/files/6708a4b498d5d8001c630864.png
VitalRouter.MRuby では、Unityのネイティブアロケータにmrubyのアロケータを接続しているため、mruby側のメモリ使用量は UnsafeUtility.Malloc の Persistentアロケータとして計上されるようになってます。
メモリプロファイラから確認できてべんり。
(スペシャルサンクス akeit0さん
mrubyによるメモリの確保や解放が行われるたびに一応、C#側のコードが呼ばれてるので、もうちょっとメモリを可視化できるツールを用意してもいいかなとおもったりしてます。
といったかんじなんですが
mrubyのAPIはかなり興味深くて、Cのコードからメタプログラミングも含めてなんでもできるし、独特の一貫性があっておもしろいです。一時期、RubyはCのAPIがよい!とか聞こえてきたのを思いだしました。僕は mrubyではじめてそれに触れましたが、なんというかドキュメントは正直いって薄くって、ソースコード読まないとほとんどなにもわからないけど逆にそれが味だよねみたいな雰囲気をかんじました。ドキュメントはもったいないと思うのでがんばってほしいなと(他人事)。
VitalRouter.MRubyはとりあえず正式リリースということにしていますが、まだまだ色々と可能性がある気がします(たとえばC#でmruby vmを実装したりとか)
僕はRubyのコードはかなり昔に書いていたけど最近ではとんと書かなくなってたんですが、ゲームの組み込み用途とかそういうDSLとしてはかなりよいと思っています。書き味はかなり好きですね。Fiberもあるし。まあ好みはあるでしょうが、ゲーム開発界隈では意外とけっこう使われているといわれているRuby、その嗜好に興味がある方は VitalRouter.MRubyをゼヒ試してみてくれると嬉しいです
(ここではあまりしていなかった使い方の説明は のREADMEを参照のこと